{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "w_XJiOdr-MSM"
   },
   "source": [
    "# Ungraded Lab: Introduction to Time Series Plots\n",
    "\n",
    "This notebook aims to show different terminologies and attributes of a time series by generating and plotting synthetic data. Trying out different prediction models on this kind of data is a good way to develop your intuition when you get hands-on with real-world data later in the course. Let's begin!"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "7OLHlG4EnF3G"
   },
   "source": [
    "## Imports\n",
    "\n",
    "You will mainly be using [Numpy](https://numpy.org) and [Matplotlib's Pyplot](https://matplotlib.org/3.8.3/api/pyplot_summary.html) library to generate the data and plot the graphs."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "gqWabzlJ63nL"
   },
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "import numpy as np"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "20wPCynItW0Z"
   },
   "source": [
    "## Plot Utilities\n",
    "\n",
    "You will be plotting several graphs in this notebook so it's good to have a utility function for that. The following code will visualize numpy arrays into a graph using Pyplot's [plot()](https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.plot.html) method. The x-axis will contain the time steps. The exact unit is not critical for this exercise so you can pretend it is either seconds, hours, year, etc. The y-axis will contain the measured values at each time step."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "sJwA96JU00pW"
   },
   "outputs": [],
   "source": [
    "def plot_series(time, series, format=\"-\", start=0, end=None, label=None):\n",
    "    \"\"\"\n",
    "    Visualizes time series data\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      series (array of int) - contains the measurements for each time step\n",
    "      format (string) - line style when plotting the graph\n",
    "      start (int) - first time step to plot\n",
    "      end (int) - last time step to plot\n",
    "      label (list of strings)- tag for the line\n",
    "    \"\"\"\n",
    "\n",
    "    # Setup dimensions of the graph figure\n",
    "    plt.figure(figsize=(10, 6))\n",
    "\n",
    "    # Plot the time series data\n",
    "    plt.plot(time[start:end], series[start:end], format)\n",
    "\n",
    "    # Label the x-axis\n",
    "    plt.xlabel(\"Time\")\n",
    "\n",
    "    # Label the y-axis\n",
    "    plt.ylabel(\"Value\")\n",
    "\n",
    "    if label:\n",
    "      plt.legend(fontsize=14, labels=label)\n",
    "\n",
    "    # Overlay a grid on the graph\n",
    "    plt.grid(True)\n",
    "\n",
    "    # Draw the graph on screen\n",
    "    plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "yVo6CcpRaW7u"
   },
   "source": [
    "## Trend\n",
    "\n",
    "The *trend* describes the general tendency of the values to go up or down as time progresses. Given a certain time period, you can see if the graph is following an upward/positive trend, downward/negative trend, or just flat. For instance, the housing prices in a good location can see a general increase in valuation as time passes. \n",
    "\n",
    "The simplest example to visualize is data that follows a straight line. You will use the function below to generate that. The `slope` argument will determine what the trend is. If you're coming from a mathematics background, you might recognize this as the [slope-intercept form](https://en.wikipedia.org/wiki/Linear_equation#Slope%E2%80%93intercept_form_or_Gradient-intercept_form) with the y-intercept being `0`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "t30Ts2KjiOIY"
   },
   "outputs": [],
   "source": [
    "def trend(time, slope=0):\n",
    "    \"\"\"\n",
    "    Generates synthetic data that follows a straight line given a slope value.\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      slope (float) - determines the direction and steepness of the line\n",
    "\n",
    "    Returns:\n",
    "      series (array of float) - measurements that follow a straight line\n",
    "    \"\"\"\n",
    "\n",
    "    # Compute the linear series given the slope\n",
    "    series = slope * time\n",
    "\n",
    "    return series"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "iJjc3G1Maefn"
   },
   "source": [
    "Here is a time series that trends upward. For a downward trend, simply replace the slope value below with a negative value (e.g. `-0.3`)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "BLt-pLiZ0nfB"
   },
   "outputs": [],
   "source": [
    "# Generate time steps. Assume 1 per day for one year (365 days)\n",
    "time = np.arange(365)\n",
    "\n",
    "# Define the slope (You can revise this)\n",
    "slope = 0.1\n",
    "\n",
    "# Generate measurements with the defined slope\n",
    "series = trend(time, slope)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, series, label=[f'slope={slope}'])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "xZhiYzuJhVoG"
   },
   "source": [
    "As you can tell, you don't need machine learning to model this behavior. You can simply solve for the equation of the line and you have the perfect prediction model. Data like this is extremely rare in real world applications though and the trend line is simply used as a guide like the one shown in the [Moore's Law](https://en.wikipedia.org/wiki/Moore%27s_law) example in class."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "WKD4nh9sauBf"
   },
   "source": [
    "## Seasonality\n",
    "\n",
    "Another attribute you may want to look for is seasonality. This refers to a recurring pattern at regular time intervals. For instance, the hourly temperature might oscillate similarly for 10 consecutive days and you can use that to predict the behavior on the next day. \n",
    "\n",
    "You can use the functions below to generate a time series with a seasonal pattern:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "89gdEnPY1Niy"
   },
   "outputs": [],
   "source": [
    "def seasonal_pattern(season_time):\n",
    "    \"\"\"\n",
    "    Just an arbitrary pattern, you can change it if you wish\n",
    "    \n",
    "    Args:\n",
    "      season_time (array of float) - contains the measurements per time step\n",
    "\n",
    "    Returns:\n",
    "      data_pattern (array of float) -  contains revised measurement values according \n",
    "                                  to the defined pattern\n",
    "    \"\"\"\n",
    "\n",
    "    # Generate the values using an arbitrary pattern\n",
    "    data_pattern = np.where(season_time < 0.4,\n",
    "                    np.cos(season_time * 2 * np.pi),\n",
    "                    1 / np.exp(3 * season_time))\n",
    "    \n",
    "    return data_pattern\n",
    "\n",
    "def seasonality(time, period, amplitude=1, phase=0):\n",
    "    \"\"\"\n",
    "    Repeats the same pattern at each period\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      period (int) - number of time steps before the pattern repeats\n",
    "      amplitude (int) - peak measured value in a period\n",
    "      phase (int) - number of time steps to shift the measured values\n",
    "\n",
    "    Returns:\n",
    "      data_pattern (array of float) - seasonal data scaled by the defined amplitude\n",
    "    \"\"\"\n",
    "    \n",
    "    # Define the measured values per period\n",
    "    season_time = ((time + phase) % period) / period\n",
    "\n",
    "    # Generates the seasonal data scaled by the defined amplitude\n",
    "    data_pattern = amplitude * seasonal_pattern(season_time)\n",
    "\n",
    "    return data_pattern"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "IiM1tygZ-bRr"
   },
   "source": [
    "The cell below shows the seasonality of the data generated because you can see the pattern every 365 time steps."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "7kaNezUk1S9l"
   },
   "outputs": [],
   "source": [
    "# Generate time steps\n",
    "time = np.arange(4 * 365 + 1)\n",
    "\n",
    "# Define the parameters of the seasonal data\n",
    "period = 365\n",
    "amplitude = 40\n",
    "\n",
    "# Generate the seasonal data\n",
    "series = seasonality(time, period=period, amplitude=amplitude)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, series)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "-Vo433h0bDLD"
   },
   "source": [
    "A time series can also contain both trend and seasonality. For example, the hourly temperature might oscillate regularly in short time frames, but it might show an upward trend if you look at multi-year data.\n",
    "\n",
    "The example below demonstrates a seasonal pattern with an upward trend:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "AyqFdaIN1oy5"
   },
   "outputs": [],
   "source": [
    "# Define seasonal parameters\n",
    "slope = 0.05\n",
    "period = 365\n",
    "amplitude = 40\n",
    "\n",
    "# Generate the data\n",
    "series = trend(time, slope) + seasonality(time, period=period, amplitude=amplitude)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, series)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "YVdJ2jNN8OHk"
   },
   "source": [
    "## Noise\n",
    "\n",
    "In practice, few real-life time series have such a smooth signal. They usually have some noise riding over that signal. The next cells will show what a noisy signal looks like:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "3kD3_zjVscBH"
   },
   "outputs": [],
   "source": [
    "def noise(time, noise_level=1, seed=None):\n",
    "    \"\"\"Generates a normally distributed noisy signal\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      noise_level (float) - scaling factor for the generated signal\n",
    "      seed (int) - number generator seed for repeatability\n",
    "\n",
    "    Returns:\n",
    "      noise (array of float) - the noisy signal\n",
    "\n",
    "    \"\"\"\n",
    "\n",
    "    # Initialize the random number generator\n",
    "    rnd = np.random.RandomState(seed)\n",
    "\n",
    "    # Generate a random number for each time step and scale by the noise level\n",
    "    noise = rnd.randn(len(time)) * noise_level\n",
    "    \n",
    "    return noise\n"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "aLvBwrKrtDzo"
   },
   "outputs": [],
   "source": [
    "# Define noise level\n",
    "noise_level = 5\n",
    "\n",
    "# Generate noisy signal\n",
    "noise_signal = noise(time, noise_level=noise_level, seed=42)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, noise_signal)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "GHa6gicgbL74"
   },
   "source": [
    "Now let's add this to the time series we generated earlier:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "2bRDx8K816N9"
   },
   "outputs": [],
   "source": [
    "# Add the noise to the time series\n",
    "series += noise_signal\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, series)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "IfIaJEgmq0FV"
   },
   "source": [
    "As you can see, the series is still trending upward and seasonal but there is more variation between time steps because of the added noise."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "SdNrh28JrHyj"
   },
   "source": [
    "## Autocorrelation\n",
    "\n",
    "Time series can also be autocorrelated. This means that measurements at a given time step is a function of previous time steps. Here are some functions that demonstrate that. Notice lines that refer to the `step` variable because this is where the computation from previous time steps happen. It will also include noise (i.e. random numbers) to make the result a bit more realistic."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "GICxGswL2aqK"
   },
   "outputs": [],
   "source": [
    "def autocorrelation(time, amplitude, seed=None):\n",
    "    \"\"\"\n",
    "    Generates autocorrelated data\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      amplitude (float) - scaling factor\n",
    "      seed (int) - number generator seed for repeatability\n",
    "\n",
    "    Returns:\n",
    "      ar (array of float) - autocorrelated data\n",
    "    \"\"\"\n",
    "\n",
    "    # Initialize random number generator \n",
    "    rnd = np.random.RandomState(seed)\n",
    "    \n",
    "    # Initialize array of random numbers equal to the length \n",
    "    # of the given time steps plus 50\n",
    "    ar = rnd.randn(len(time) + 50)\n",
    "    \n",
    "    # Set first 50 elements to a constant\n",
    "    ar[:50] = 100\n",
    "    \n",
    "    # Define scaling factors\n",
    "    phi1 = 0.5\n",
    "    phi2 = -0.1\n",
    "\n",
    "    # Autocorrelate element 51 onwards with the measurement at \n",
    "    # (t-50) and (t-30), where t is the current time step\n",
    "    for step in range(50, len(time) + 50):\n",
    "        ar[step] += phi1 * ar[step - 50]\n",
    "        ar[step] += phi2 * ar[step - 33]\n",
    "    \n",
    "    # Get the autocorrelated data and scale with the given amplitude.\n",
    "    # The first 50 elements of the original array is truncated because\n",
    "    # those are just constant and not autocorrelated.\n",
    "    ar = ar[50:] * amplitude\n",
    "\n",
    "    return ar"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "MVM204K66bnC"
   },
   "outputs": [],
   "source": [
    "# Use time steps from previous section and generate autocorrelated data\n",
    "series = autocorrelation(time, amplitude=10, seed=42)\n",
    "\n",
    "# Plot the first 200 elements to see the pattern more clearly\n",
    "plot_series(time[:200], series[:200])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "0auuzEwJCcvO"
   },
   "source": [
    "Here is a more straightforward autocorrelation function which just computes a value from the previous time step."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "SLRo57dpFChw"
   },
   "outputs": [],
   "source": [
    "def autocorrelation(time, amplitude, seed=None):\n",
    "    \"\"\"\n",
    "    Generates autocorrelated data\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      amplitude (float) - scaling factor\n",
    "      seed (int) - number generator seed for repeatability\n",
    "\n",
    "    Returns:\n",
    "      ar (array of float) - generated autocorrelated data\n",
    "    \"\"\"\n",
    "\n",
    "    # Initialize random number generator \n",
    "    rnd = np.random.RandomState(seed)\n",
    "\n",
    "    # Initialize array of random numbers equal to the length \n",
    "    # of the given time steps plus an additional step\n",
    "    ar = rnd.randn(len(time) + 1)\n",
    "\n",
    "    # Define scaling factor\n",
    "    phi = 0.8\n",
    "\n",
    "    # Autocorrelate element 11 onwards with the measurement at \n",
    "    # (t-1), where t is the current time step\n",
    "    for step in range(1, len(time) + 1):\n",
    "        ar[step] += phi * ar[step - 1]\n",
    "    \n",
    "    # Get the autocorrelated data and scale with the given amplitude.\n",
    "    ar = ar[1:] * amplitude\n",
    "    \n",
    "    return ar"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "SRvSNVjJFHDk"
   },
   "outputs": [],
   "source": [
    "# Use time steps from previous section and generate autocorrelated data\n",
    "series = autocorrelation(time, amplitude=10, seed=42)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time[:200], series[:200])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "g_VymebiN7df"
   },
   "source": [
    "Another autocorrelated time series you might encounter is one where it decays predictably after random spikes. You will first define the function that generates these spikes below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "iBfpCbu6jsaB"
   },
   "outputs": [],
   "source": [
    "def impulses(time, num_impulses, amplitude=1, seed=None):\n",
    "    \"\"\"\n",
    "    Generates random impulses\n",
    "\n",
    "    Args:\n",
    "      time (array of int) - contains the time steps\n",
    "      num_impulses (int) - number of impulses to generate\n",
    "      amplitude (float) - scaling factor\n",
    "      seed (int) - number generator seed for repeatability\n",
    "\n",
    "    Returns:\n",
    "      series (array of float) - array containing the impulses\n",
    "    \"\"\"\n",
    "\n",
    "    # Initialize random number generator \n",
    "    rnd = np.random.RandomState(seed)\n",
    "\n",
    "    # Generate random numbers\n",
    "    impulse_indices = rnd.randint(len(time), size=num_impulses)\n",
    "\n",
    "    # Initialize series\n",
    "    series = np.zeros(len(time))\n",
    "\n",
    "    # Insert random impulses\n",
    "    for index in impulse_indices:\n",
    "        series[index] += rnd.rand() * amplitude\n",
    "\n",
    "    return series    "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "EepyLI5MHycW"
   },
   "source": [
    "You will use the function above to generate a series with 10 random impulses."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "BJ1kXWNLg_BD"
   },
   "outputs": [],
   "source": [
    "# Generate random impulses\n",
    "impulses_signal = impulses(time, num_impulses=10, seed=42)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, impulses_signal)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "5wBGIJVDHuxF"
   },
   "source": [
    "Now that you have the series, you will next define the function that will decay the next values after it spikes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "yW91NpqhH1OT"
   },
   "outputs": [],
   "source": [
    "def autocorrelation_impulses(source, phis):\n",
    "    \"\"\"\n",
    "    Generates autocorrelated data from impulses\n",
    "\n",
    "    Args:\n",
    "      source (array of float) - contains the time steps with impulses\n",
    "      phis (dict) - dictionary containing the lag time and decay rates\n",
    "\n",
    "    Returns:\n",
    "      ar (array of float) - generated autocorrelated data\n",
    "    \"\"\"\n",
    "\n",
    "    # Copy the source\n",
    "    ar = source.copy()\n",
    "\n",
    "    # Compute new series values based on the lag times and decay rates\n",
    "    for step, value in enumerate(source):\n",
    "        for lag, phi in phis.items():\n",
    "            if step - lag > 0:\n",
    "              ar[step] += phi * ar[step - lag]\n",
    "\n",
    "    return ar"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "1Dm8P4LWIv0-"
   },
   "source": [
    "You can then use the function to generate the decay after the spikes. Here is one example that generates the next value from the previous time step (i.e. `t-1`, where `t` is the current time step):"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "KwPD3dgOD80H"
   },
   "outputs": [],
   "source": [
    "# Use the impulses from the previous section and generate autocorrelated data\n",
    "series = autocorrelation_impulses(impulses_signal, {1: 0.99})\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, series)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "2Rs8jUawKUo6"
   },
   "source": [
    "Here is another example where the next values are computed from those in `t-1` and `t-50`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "Hj0kK0cMJuXY"
   },
   "outputs": [],
   "source": [
    "# Use the impulses from the previous section and generate autocorrelated data\n",
    "series = autocorrelation_impulses(impulses_signal, {1: 0.70, 50: 0.2})\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time, series)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "zMd5D42fKRNq"
   },
   "source": [
    "Autocorrelated data can also ride a trend line and it will look like below."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "9MZ2sCmM8XPU"
   },
   "outputs": [],
   "source": [
    "# Generate autocorrelated data with an upward trend\n",
    "series = autocorrelation(time, 10, seed=42) + trend(time, 2)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time[:200], series[:200])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "tLVe-DNqKgKk"
   },
   "source": [
    "Similarly, seasonality can also be added to this data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "hqx5et9Bzp5e"
   },
   "outputs": [],
   "source": [
    "# Generate autocorrelated data with an upward trend\n",
    "series = autocorrelation(time, 10, seed=42) + seasonality(time, period=50, amplitude=150) + trend(time, 2)\n",
    "\n",
    "# Plot the results\n",
    "plot_series(time[:200], series[:200])\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "dtxPKnNfLAhl"
   },
   "source": [
    "## Non-stationary Time Series\n",
    "\n",
    "It is also possible for the time series to break an expected pattern. As mentioned in the lectures, big events can alter the trend or seasonal behavior of the data. It would look something like below where the graph shifted to a downward trend at time step = 200."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "id": "qb5echI7rHqA"
   },
   "outputs": [],
   "source": [
    "# Generate data with positive trend\n",
    "series = autocorrelation(time, 10, seed=42) + seasonality(time, period=50, amplitude=150) + trend(time, 2)\n",
    "\n",
    "# Generate data with negative trend\n",
    "series2 = autocorrelation(time, 5, seed=42) + seasonality(time, period=50, amplitude=2) + trend(time, -1) + 550\n",
    "\n",
    "# Splice the downward trending data into the first one at time step = 200\n",
    "series[200:] = series2[200:]\n",
    "\n",
    "# Plot the result\n",
    "plot_series(time[:300], series[:300])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "MhSpu1SzMXbq"
   },
   "source": [
    "In cases like this, you may want to train your model on the later steps (i.e. starting at t=200) since these present a stronger predictive signal to future time steps."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {
    "id": "PY78_SuaLHAm"
   },
   "source": [
    "## Wrap Up\n",
    "\n",
    "This concludes this introduction to time series terminologies and attributes. You also saw how to generate them and you will use these to test different forecasting techniques in the next sections. See you there!"
   ]
  }
 ],
 "metadata": {
  "colab": {
   "collapsed_sections": [],
   "name": "C4_W1_Lab_1_time_series.ipynb",
   "private_outputs": true,
   "provenance": []
  },
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.6"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
